--
-- PlanetaLibre -- An Atom and RSS feed aggregator for Gemini written in Lua.
--
-- Copyright (C) 2023-2024  Ricardo García Jiménez <ricardogj08@riseup.net>
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-- GNU General Public License for more details.
--
-- You should have received a copy of the GNU General Public License
-- along with this program.  If not, see <https://www.gnu.org/licenses/>.
--

-- NOTA: Utilizar solo programación estructurada.

-- Módulos.
local socket = require('socket')
local socket_url = require('socket.url')
local ssl = require('ssl')
local uuid = require('uuid')
require('feedparser')

-- Configuración de la base de datos.
local driver = require('luasql.sqlite3')
local sql_env = assert(driver.sqlite3())

-- Configuraciones de la aplicación.
local settings = {
  gemini = {
    scheme = 'gemini',
    host = '/',
    port = 1965,
  },
  ssl = {
    mode = 'client',
    protocol = 'tlsv1_2'
  },
  mime = {
    'application/xml',
    'text/xml',
    'application/atom+xml',
    'application/rss+xml'
  },
  timeout = 8,
  file = 'feeds.txt',
  output = '.',
  header = 'header.gemini',
  footer = 'footer.gemini',
  capsule = 'PlanetaLibre',
  domain = 'localhost',
  limit = 64,
  lang = 'es',
  repo = 'https://notabug.org/ricardogj08/planetalibre',
  version = '4.0',
  license = 'CC-BY-4.0' -- https://spdx.org/licenses
}

-- Ejecuta las migraciones de la base de datos.
local function migrations()
  -- Crea la tabla de las cápsulas.
  assert(sql_conn:execute([[
    CREATE TABLE IF NOT EXISTS capsules (
      id CHAR(36) NOT NULL,
      link TEXT NOT NULL,
      name VARCHAR(125) NOT NULL,
      created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
      updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
      CONSTRAINT capsules_id_primary PRIMARY KEY(id),
      CONSTRAINT capsules_link_unique UNIQUE(link)
    )
  ]]))

  -- Crea la tabla de las publicaciones.
  assert(sql_conn:execute([[
    CREATE TABLE IF NOT EXISTS posts (
      id CHAR(36) NOT NULL,
      capsule_id CHAR(36) NOT NULL,
      link TEXT NOT NULL,
      title VARCHAR(255) NOT NULL,
      created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
      updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
      CONSTRAINT posts_id_primary PRIMARY KEY(id),
      CONSTRAINT posts_capsule_id_foreign FOREIGN KEY(capsule_id)
        REFERENCES capsules(id)
        ON DELETE CASCADE
        ON UPDATE RESTRICT,
      CONSTRAINT posts_link_unique UNIQUE(link)
    )
  ]]))
end

-- Analiza una URL.
local function parse_url(url)
  return socket_url.parse(url, settings.gemini)
end

-- Construye una URL.
local function build_url(url)
  return socket_url.build(parse_url(url))
end

-- Construye una URL de PlanetaLibre.
local function base_url(uri)
  local parsed_path = socket_url.parse_path(settings.domain)
  local parsed_url = settings.gemini

  -- Obtiene el dominio desde la configuración y
  -- mantiene los subdirectorios si se encuentran presentes.
  parsed_url.host = table.remove(parsed_path, 1)

  -- Agrega la uri con los subdirectorios si se encuentran presentes.
  table.insert(parsed_path, uri)

  -- Convierte el array de uris a un string.
  parsed_url.path = '/'..socket_url.build_path(parsed_path)

  return socket_url.build(parsed_url)
end

-- Cliente de peticiones para el protocolo Gemini.
local client = socket.protect(function(url)
  ::client::

  local response = nil

  -- Analiza la URL de la petición.
  local parsed_url, err = parse_url(url)

  if err then
    return response, err
  end

  -- Comprueba el protocolo de la petición.
  if parsed_url.scheme ~= settings.gemini.scheme then
    err = 'Invalid url scheme'
    return response, err
  end

  -- Crea un objeto TCP maestro.
  local conn = assert(socket.tcp())

  -- Crea una función try que cierra el objeto TCP en caso de errores.
  local try = socket.newtry(function()
    conn:close()
  end)

  -- Define el tiempo máximo de espera por bloque en modo no seguro.
  conn:settimeout(settings.timeout)

  -- Realiza la conexión a un host remoto y
  -- transforma el objeto TCP maestro a cliente.
  try(conn:connect(parsed_url.host, settings.gemini.port))

  -- Transforma el objeto TCP cliente para conexiones seguras.
  conn = try(ssl.wrap(conn, settings.ssl))

  -- Define el tiempo máximo de espera por bloque en modo seguro.
  conn:settimeout(settings.timeout)

  -- Define el nombre del host al que se intenta conectar.
  conn:sni(parsed_url.host)

  -- Realiza la conexión segura.
  try(conn:dohandshake())

  url = socket_url.build(parsed_url)

  -- Construye la petición.
  local request = string.format('%s\r\n', url)

  -- Realiza la petición.
  try(conn:send(request))

  -- Obtiene el encabezado de la respuesta.
  local header = conn:receive('*l')

  -- Obtiene el código de estado y la meta de la respuesta.
  local status, meta = string.match(header, '(%d+)%s+(.+)')

  status = string.sub(status, 1, 1)

  local redirect = false

  -- Comprueba el código de estado del encabezado.
  if status == '2' then
    -- Comprueba el mime type de la respuesta.
    for _, mime in ipairs(settings.mime) do
      -- Obtiene el cuerpo de la respuesta.
      if string.find(meta, mime, 1, true) then
        response = conn:receive('*a')
        break
      end
    end

    if not response then
      err = 'Invalid mime type'
    end
  elseif status == '3' then
    redirect = true
  elseif status == '4' or status == '5' then
    err = meta
  elseif status == '6' then
    err = 'Client certificate required'
  else
    err = 'Invalid response from server'
  end

  -- Cierra el objeto TCP cliente.
  conn:close()

  -- Soluciona las redirecciones.
  if redirect then
    url = socket_url.absolute(url, meta)
    goto client
  end

  return response, err
end)

-- Imprime mensajes de éxito.
local function log_success(message)
  print(string.format('[success] %s', message))
end

-- Imprime mensajes de errores.
local function log_error(title, description)
  print(string.format('[error] %s - %s', title, description))
end

-- Imprime mensajes informativos.
local function log_info(message)
  print(string.format('==== %s ====', message))
end

-- Obtiene un timestamp del tiempo actual en UTC.
local function now_timestamp()
  return os.time(os.date('!*t'))
end

-- Convierte un timestamp a un datetime.
local function timestamp_to_datetime(timestamp)
  return os.date('%F %T', timestamp)
end

-- Obtiene un datetime del tiempo actual en UTC.
local function now_datetime()
  return timestamp_to_datetime(now_timestamp())
end

-- Obtiene un timestamp del tiempo actual desde hace un año en UTC.
local function now_prev_year_timestamp()
  return now_timestamp() - 365 * 24 * 60 * 60
end

-- Obtiene un datetime del tiempo actual desde hace un año en UTC.
local function now_prev_year_datetime()
  return timestamp_to_datetime(now_prev_year_timestamp())
end

-- Convierte un datetime a un timestamp.
local function datetime_to_timestamp(datetime)
  local pattern = '(%d+)-(%d+)-(%d+)%s+(%d+):(%d+):(%d+)'
  local values = { string.match(datetime, pattern) }
  local keys = { 'year', 'month', 'day', 'hour', 'min', 'sec' }
  local timetable = {}

  -- Genera el timetable del datetime.
  for index, key in ipairs(keys) do
    timetable[key] = values[index]
  end

  return os.time(timetable)
end

-- Convierte un datetime a un dateatom.
local function datetime_to_dateatom(datetime)
  return os.date('%FT%TZ', datetime_to_timestamp(datetime))
end

-- Obtiene un dateatom del tiempo actual en UTC.
local function now_dateatom()
  return datetime_to_dateatom(now_datetime())
end

-- Convierte un datetime a un date.
local function datetime_to_date(datetime)
  return os.date('%F', datetime_to_timestamp(datetime))
end

-- Escapa los caracteres especiales de un valor para la base de datos.
local function escape(value)
  return sql_conn:escape(value)
end

-- Consulta la información de una cápsula.
local function find_capsule_by(field, value)
  local cursor = assert(sql_conn:execute(string.format([[
    SELECT id, name
    FROM capsules
    WHERE %s = '%s'
    LIMIT 1
  ]], escape(field),
      escape(value))))

  local capsule = cursor:fetch({}, 'a')

  cursor:close()

  return capsule
end

-- Registra la información de una nueva cápsula.
local function insert_capsule(data)
  local id = uuid()

  assert(sql_conn:execute(string.format([[
    INSERT INTO capsules(id, name, link, created_at, updated_at)
    VALUES('%s', '%s', '%s', '%s', '%s')
  ]], escape(id),
      escape(data.name),
      escape(data.link),
      escape(now_datetime()),
      escape(now_datetime()))))

  return find_capsule_by('id', id)
end

-- Modifica la información de una cápsula.
local function update_capsule(data, id)
  assert(sql_conn:execute(string.format([[
    UPDATE capsules
    SET name = '%s', updated_at = '%s'
    WHERE id = '%s'
  ]], escape(data.name),
      escape(now_datetime()),
      escape(id))))
end

-- Consulta la información de una publicación.
local function find_post_by(field, value)
  local cursor = assert(sql_conn:execute(string.format([[
    SELECT id, title, updated_at
    FROM posts
    WHERE %s = '%s'
    LIMIT 1
  ]], escape(field),
      escape(value))))

  local post = cursor:fetch({}, 'a')

  cursor:close()

  return post
end

-- Registra la información de una nueva publicación.
local function insert_post(data)
  local id = uuid()

  assert(sql_conn:execute(string.format([[
    INSERT INTO posts(id, capsule_id, title, link, created_at, updated_at)
    VALUES('%s', '%s', '%s', '%s', '%s', '%s')
  ]], escape(id),
      escape(data.capsule_id),
      escape(data.title),
      escape(data.link),
      escape(now_datetime()),
      escape(data.updated_at))))

    return find_post_by('id', id)
end

-- Modifica la información de una publicación.
local function update_post(data, id)
  assert(sql_conn:execute(string.format([[
    UPDATE posts
    SET title = '%s', updated_at = '%s'
    WHERE id = '%s'
  ]], escape(data.title),
      escape(data.updated_at),
      escape(id))))
end

-- Obtiene todas las publicaciones ordenadas por fecha de modificación.
local function get_posts()
  local cursor = assert(sql_conn:execute(string.format([[
    SELECT c.name AS capsule_name, c.link AS capsule_link, p.title, p.link, p.updated_at
    FROM posts AS p
    INNER JOIN capsules AS c
      ON p.capsule_id = c.id
    ORDER BY p.updated_at DESC
    LIMIT %u
  ]], escape(settings.limit))))

  return function()
    return cursor:fetch({}, 'a')
  end
end

-- Comprueba la información de una cápsula
-- desde la información del feed.
local function check_capsule(feed_info)
  -- Establece la información de la cápsula desde el feed.
  local data = { name = feed_info.title, link = build_url(feed_info.link) }

  -- Comprueba si la cápsula se encuentra registrada.
  local capsule = find_capsule_by('link', data.link)

  -- Registra la información de la cápsula si no existe en la base de datos.
  if not capsule then
    capsule = insert_capsule(data)
  -- Actualiza el nombre de la cápsula si es diferente en la base de datos.
  elseif capsule.name ~= data.name then
    update_capsule({ name = data.name }, capsule.id)
  end

  return capsule
end

-- Comprueba la información de las publicaciones
-- desde la información de las entradas del feed.
local function check_posts(entries, capsule, prev_timestamp)
  -- Registra cada entrada de la cápsula con
  -- una antigüedad mayor desde hace un año.
  for _, entry in ipairs(entries) do
    if entry.updated_parsed > prev_timestamp then
      -- Establece la información de la publicación desde las entradas del feed.
      local data = {
        title = entry.title,
        link = build_url(entry.link),
        capsule_id = capsule.id,
        updated_at = timestamp_to_datetime(entry.updated_parsed)
      }

      -- Comprueba si la publicación se encuentra registrada.
      local post = find_post_by('link', data.link)

      -- Registra la información de la publicación si no existe en la base de datos.
      if not post then
        post = insert_post(data)
      -- Actualiza el título y la fecha de modificación de
      -- la publicación si es diferente en la base de datos.
      elseif post.title ~= data.title or
        post.updated_at ~= data.updated_at
      then
        update_post({ title = data.title, updated_at = data.updated_at }, post.id)
      end
    end
  end
end

-- Escanea un archivo externo con las URLs de los feeds
-- y almacena cada una de sus entradas en la base de datos.
local function scan_feeds()
  local file = assert(io.open(settings.file, 'r'))
  local prev_timestamp = now_prev_year_timestamp()

  for url in file:lines() do
    local status, err = pcall(function()
      -- Obtiene el cuerpo del feed desde la url del archivo.
      local feed = assert(client(url))

      -- Analiza el cuerpo del feed.
      local parsed_feed = assert(feedparser.parse(feed))

      -- Comprueba la información de la cápsula.
      local capsule = check_capsule(parsed_feed.feed)

      -- Comprueba la información de las entradas.
      check_posts(parsed_feed.entries, capsule, prev_timestamp)
    end)

    if status then
      log_success(url)
    else
      log_error(url, err)
    end
  end

  file:close()
end

-- Construye un path desde la salida del programa.
local function build_path(path)
  return string.format('%s/%s', settings.output, path)
end

-- Genera el encabezado del feed de Atom de PlanetaLibre.
local function open_atomfeed()
  local feed_url = base_url('atom.xml')
  local home_url = base_url('index.gemini')
  return string.format([[
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="%s">
  <title>%s</title>
  <link href="%s" rel="self" type="application/atom+xml"/>
  <link href="%s" rel="alternate" type="text/gemini"/>
  <updated>%s</updated>
  <id>%s</id>
  <author>
    <name>%s</name>
    <uri>%s</uri>
  </author>
  <rights>%s</rights>
  <generator uri="%s" version="%s">
    PlanetaLibre
  </generator>
]], settings.lang,
    settings.capsule,
    feed_url,
    home_url,
    now_dateatom(),
    feed_url,
    settings.capsule,
    home_url,
    settings.license,
    settings.repo,
    settings.version)
end

-- Genera la etiqueta de cierre del feed de Atom de PlanetaLibre.
local function close_atomfeed()
  return '</feed>\n'
end

-- Genera una entrada para el feed de Atom de PlanetaLibre.
local function entry_atomfeed(post)
  return string.format([[
  <entry>
    <title>%s</title>
    <link href="%s" rel="alternate" type="text/gemini"/>
    <id>%s</id>
    <updated>%s</updated>
    <author>
      <name>%s</name>
      <uri>%s</uri>
    </author>
  </entry>
]], post.title,
    post.link,
    post.link,
    datetime_to_dateatom(post.updated_at),
    post.capsule_name,
    post.capsule_link)
end

-- Genera un link de Gemini.
local function gemtext_link(link, title, description)
  return string.format('=> %s %s - %s\n', link, title, description)
end

-- Genera un heading de Gemini.
local function gemtext_heading(title)
  return string.format('\n### %s\n\n', title)
end

-- Genera la cápsula y el feed de Atom de PlanetaLibre.
local function generate_planetalibre()
  local homepage = assert(io.open(build_path('index.gemini'), 'w+'))
  local atomfeed = assert(io.open(build_path('atom.xml'), 'w+'))
  local header = io.open(settings.header)
  local test_date = nil

  -- Agrega el header en el archivo de la página principal.
  if header then
    homepage:write(header:read('*a'))
    header:close()
  end

  -- Agrega el encabezado en el archivo del feed de atom.
  atomfeed:write(open_atomfeed())

  -- Agrega las publicaciones en el archivo
  -- de la página principal y el feed de atom.
  for post in get_posts() do
    -- Obtiene la fecha de la publicación.
    local post_date = datetime_to_date(post.updated_at)

    -- Agrupa las publicaciones por día.
    if post_date ~= test_date then
      test_date = post_date
      homepage:write(gemtext_heading(test_date))
    end

    -- Agrega las publicaciones en el archivo
    -- de la página principal y el feed de atom.
    homepage:write(gemtext_link(post.link, post.capsule_name, post.title))
    atomfeed:write(entry_atomfeed(post))
  end

  -- Agrega la etiqueta de cierre en el archivo del feed de atom.
  atomfeed:write(close_atomfeed())
  atomfeed:close()

  local footer = io.open(settings.footer)

  -- Agrega el footer en el archivo de la página principal.
  if footer then
    homepage:write('\n'..footer:read('*a'))
    footer:close()
  end

  homepage:close()
end

-- Imprime un mensaje de ayuda.
local function shelp()
  print(string.format([[
PlanetaLibre %s - An Atom and RSS feed aggregator for Gemini written in Lua.

Synopsis:
  planetalibre [OPTIONS]

Options:
  --capsule <STRING> - Capsule name [default: PlanetaLibre].
  --domain  <STRING> - Capsule domain name [default: localhost].
  --file    <FILE>   - File to read feed URLs from Gemini [default: feeds.txt].
  --footer  <FILE>   - Homepage footer [default: footer.gemini].
  --header  <FILE>   - Homepage header [default: header.gemini].
  --lang    <STRING> - Capsules language [default: es].
  --license <STRING> - Capsule license [default: CC-BY-4.0].
  --limit   <NUMBER> - Maximum number of posts [default: 64].
  --output  <PATH>   - Output directory [default: .].]], settings.version))
  os.exit()
end

-- Opciones de uso en la terminal.
local function usage()
  for itr = 1, #arg, 2 do
    local option = arg[itr]
    local param = arg[itr + 1] or shelp()

    if option == '--capsule' then
      settings.capsule = param
    elseif option == '--domain' then
      settings.domain = param
    elseif option == '--file' then
      settings.file = param
    elseif option == '--footer' then
      settings.footer = param
    elseif option == '--header' then
      settings.header = param
    elseif option == '--lang' then
      settings.lang = param
    elseif option == '--license' then
      settings.license = param
    elseif option == '--limit' then
      settings.limit = param
    elseif option == '--output' then
      settings.output = param
    else
      shelp()
    end
  end
end

-- Elimina publicaciones y cápsulas antigüas
-- desde hace un año en la base de datos.
local function clean_database()
  local prev_datetime = escape(now_prev_year_datetime())

  assert(sql_conn:execute(string.format([[
    DELETE FROM posts
    WHERE updated_at < '%s'
  ]], prev_datetime)))

  assert(sql_conn:execute(string.format([[
    DELETE FROM capsules
    WHERE id IN(
      SELECT c.id
      FROM capsules AS c
      LEFT JOIN posts AS p
        ON c.id = p.capsule_id
      WHERE c.updated_at < '%s'
      GROUP BY c.id
      HAVING COUNT(p.id) = 0
    )
  ]], prev_datetime)))
end

-- Función principal.
local function main()
  usage()

  log_info('Running database migrations')
  sql_conn = assert(sql_env:connect('database.sqlite'))
  migrations()
  uuid.seed()

  log_info('Scanning feed URLs from Gemini')
  scan_feeds()

  log_info('Generating homepage and Atom feed')
  generate_planetalibre()

  log_info('Deleting old posts and capsules')
  clean_database()

  -- Cierra la conexión con la base de datos.
  sql_conn:close()
  sql_env:close()
end

main()
